18장. 연산자와 Kotlin Convention
우리는 지금까지 +, -, ==, < 같은 기호를 자연스럽게 써 왔습니다.
숫자를 더하고, 값을 비교하고,
문자열을 이어 붙였습니다.
그런데 이런 기호들을
내가 만든 클래스에도 붙여 쓸 수 있다면 어떨까요?
예를 들어 두 개의 “돈” 객체를 money1 + money2처럼
더할 수 있다면 코드가 훨씬 읽기 편해질 것입니다.
이 장에서는 코틀린이 연산자(기호)를
어떻게 우리 마음대로 정의하게 해 주는지 배웁니다.
이것을 연산자 오버로딩(operator overloading)이라고 부릅니다.
그리고 코틀린이 미리 정해 둔 약속,
즉 컨벤션(convention)이 무엇인지도 함께 살펴봅니다.
마지막으로 이 편리한 기능을
언제 쓰고 언제 참아야 하는지도 이야기합니다.
18.1 Operator Overloading (연산자 오버로딩이란)
연산자도 사실은 함수다
먼저 놀라운 사실 하나부터 짚고 가겠습니다.
우리가 쓰던 + 기호는
사실 함수를 부르는 것과 똑같습니다.
val sum = 1 + 2
이 코드는 겉으로는 기호를 쓴 것 같지만,
코틀린 내부에서는 이렇게 동작합니다.
val sum = 1.plus(2)
즉, 1 + 2는 1이라는 숫자에게
“2를 더해 줘(plus)“라고 부탁하는 것입니다.
기호는 그저 함수를 예쁘게 부르는
겉모습일 뿐입니다.
연산자(기호)는
약속된 이름의 함수를 부르는 지름길이다.
오버로딩이라는 말의 뜻
오버로딩(overloading)이라는 단어를
풀어 보겠습니다.
여기서는 “원래 있던 기호에
새로운 의미를 얹는다“는 뜻으로 이해하면 됩니다.
+는 원래 숫자를 더하는 기호였습니다.
그런데 이 +에 “내 클래스끼리 더하는 의미“를
새로 얹는 것입니다.
비유하자면 이렇습니다.
“더하기“라는 말은 원래 숫자에만 썼지만,
“장바구니에 상품을 더한다“처럼
새로운 대상에도 쓸 수 있게 넓혀 주는 것이다.
operator 키워드로 정의하기
내 클래스에 +를 붙여 쓰려면plus라는 이름의 함수를 만들면 됩니다.
단, 함수 앞에 operator라는 표시를
꼭 붙여야 합니다.
돈을 표현하는 간단한 클래스를 만들어 보겠습니다.
class Money(val amount: Int) {
operator fun plus(other: Money): Money {
return Money(this.amount + other.amount)
}
}
operator: 이 함수를 연산자로 쓰겠다는 표시plus:+기호에 연결되는 약속된 이름other:+오른쪽에 오는 값
이제 두 개의 Money를
기호 하나로 더할 수 있습니다.
val a = Money(1000)
val b = Money(2000)
val total = a + b // a.plus(b)와 같음
println(total.amount) // 3000
a + b라고 썼지만
실제로는 a.plus(b)가 불린 것입니다.
기호 덕분에 코드가
훨씬 사람의 말처럼 읽힙니다.
대표적인 산술 연산자 이름
기호마다 연결되는 함수 이름은
코틀린이 미리 정해 두었습니다.
이 “미리 정해 둔 약속“이 바로
컨벤션(convention)입니다.
자주 쓰는 산술 연산자를 표로 정리해 보겠습니다.
| 기호 | 함수 이름 | 의미 |
|---|---|---|
+ | plus | 더하기 |
- | minus | 빼기 |
* | times | 곱하기 |
/ | div | 나누기 |
% | rem | 나머지 |
예를 들어 a * b라고 쓰면
코틀린은 a.times(b)를 찾아 부릅니다.
우리가 할 일은 그저
약속된 이름으로 함수를 만드는 것뿐입니다.
Java와 비교하면
자바를 아는 분이라면 이 기능이
꽤 새롭게 느껴질 것입니다.
자바에는 연산자 오버로딩이 없습니다.
그래서 객체끼리 더하려면 이렇게 써야 합니다.
// 자바
Money total = a.plus(b);
기호를 못 쓰니
항상 함수 이름을 직접 불러야 합니다.
코틀린은 여기서 한 걸음 더 나아가,
그 함수를 기호로도 부를 수 있게 열어 준 것입니다.
val total = a + b
같은 동작이지만
읽는 느낌이 사뭇 다릅니다.
18.2 비교 연산 (compareTo, 부등호)
크고 작음을 비교하고 싶을 때
이번에는 부등호를 다뤄 보겠습니다.<, >, <=, >= 같은 기호입니다.
숫자는 당연히 크고 작음을 비교할 수 있습니다.
println(3 < 5) // true
그런데 우리가 만든 Money끼리도
“어느 쪽이 더 큰지” 비교하고 싶을 수 있습니다.
val a = Money(1000)
val b = Money(2000)
println(a < b) // 이게 가능할까?
기본 상태로는 불가능합니다.
코틀린은 Money의 크기를 알지 못하기 때문입니다.
compareTo라는 약속
부등호에 연결된 함수 이름은compareTo입니다.
이 함수 하나만 만들면<, >, <=, >=가 모두 동작합니다.
compareTo는 두 값을 비교한 결과를
정수(Int)로 돌려주기로 약속되어 있습니다.
- 나 자신이 더 크면 : 양수(예:
1) - 서로 같으면 :
0 - 나 자신이 더 작으면 : 음수(예:
-1)
Money에 적용해 보겠습니다.
class Money(val amount: Int) {
operator fun compareTo(other: Money): Int {
return this.amount.compareTo(other.amount)
}
}
여기서는 직접 계산하지 않고,
Int가 이미 가진 compareTo에게 일을 맡겼습니다.
이제 부등호를 자유롭게 쓸 수 있습니다.
val a = Money(1000)
val b = Money(2000)
println(a < b) // true
println(a > b) // false
println(a >= b) // false
부등호는 어떻게 판단할까
한 가지 궁금증이 생길 수 있습니다.
우리는 compareTo 하나만 만들었는데,
어떻게 <와 >가 둘 다 동작할까요?
비밀은 간단합니다.
코틀린이 부등호를 만나면compareTo의 결과를 0과 비교합니다.
예를 들어 a < b는
내부에서 이렇게 바뀝니다.
a.compareTo(b) < 0
a가 더 작으면 compareTo가 음수를 돌려주고,
음수는 0보다 작으므로 a < b가 true가 됩니다.
부등호 하나하나를 따로 만들 필요가 없다.
compareTo하나가 모든 부등호를 책임진다.
Comparable과 정렬
값의 크기를 비교할 수 있는 클래스는Comparable이라는 이름을 붙여 표현합니다.
정확히는 Comparable<T> 인터페이스를
구현(implements)한다고 말합니다.
(인터페이스는 5부에서 자세히 다룹니다.)
class Money(val amount: Int) : Comparable<Money> {
override operator fun compareTo(other: Money): Int {
return this.amount.compareTo(other.amount)
}
}
이렇게 해 두면 좋은 점이 하나 있습니다.
목록을 정렬(sort)할 때 그대로 쓸 수 있습니다.
val list = listOf(Money(3000), Money(1000), Money(2000))
val sorted = list.sorted()
sorted.forEach { println(it.amount) }
// 1000
// 2000
// 3000
sorted()는 내부에서 compareTo를 이용해
작은 값부터 차례로 줄을 세웁니다.
우리가 크기 기준만 한 번 정해 주면,
정렬은 코틀린이 알아서 해 주는 것입니다.
18.3 contains (in 연산자)
“안에 들어 있나?“를 묻는 in
이번에는 in이라는 조금 특별한 기호를 봅니다.
in은 “어떤 값이 그 안에 들어 있는가“를
묻는 연산자입니다.
리스트에서 자주 써 봤을 것입니다.
val numbers = listOf(1, 2, 3)
println(2 in numbers) // true
println(5 in numbers) // false
2 in numbers는
“2가 numbers 안에 있니?“라는 뜻입니다.
읽는 그대로 의미가 통해서
아주 자연스럽습니다.
contains라는 약속
in에 연결된 함수 이름은contains입니다.
contains는 영어로 “포함한다“는 뜻이고,
결과를 참/거짓(Boolean)으로 돌려줍니다.
여기서 방향을 눈여겨봐야 합니다.
2 in numbers
이 코드는 내부에서 이렇게 바뀝니다.
numbers.contains(2)
즉, in 오른쪽에 있는 대상(numbers)이contains의 주인이 됩니다.
찾는 값(2)은 괄호 안으로 들어갑니다.
값 in 대상은대상.contains(값)으로 바뀐다.
직접 만들어 보는 contains
좌석 범위를 표현하는
간단한 클래스를 만들어 보겠습니다.
1번부터 10번까지의 좌석이 있다고 합시다.
class SeatRange(val start: Int, val end: Int) {
operator fun contains(seat: Int): Boolean {
return seat in start..end
}
}
이제 어떤 좌석 번호가
이 범위 안에 있는지 기호로 물을 수 있습니다.
val range = SeatRange(1, 10)
println(5 in range) // true
println(15 in range) // false
5 in range는range.contains(5)를 부른 결과입니다.
숫자 5는 1부터 10 사이에 있으므로true가 나옵니다.
!in도 함께 따라온다
contains 하나를 만들면
반대 표현인 !in도 자동으로 쓸 수 있습니다.
!in은 “안에 없다“는 뜻입니다.
println(15 !in range) // true (범위 밖이다)
부등호에서 봤던 원리와 같습니다.
코틀린이 contains의 결과를 뒤집어!in을 처리해 주는 것입니다.
18.4 get과 set (인덱스 접근 [])
대괄호로 값을 꺼내고 넣기
리스트에서 특정 위치의 값을 꺼낼 때
대괄호([])를 써 봤을 것입니다.
val list = listOf("a", "b", "c")
println(list[0]) // a
list[0]은 “0번째 값을 꺼내 줘“라는 뜻입니다.
이 대괄호 접근도
사실은 약속된 함수를 부르는 것입니다.
값을 꺼내는 get
값을 꺼내는 대괄호에 연결된 이름은get입니다.
list[0]
이 코드는 내부에서 이렇게 바뀝니다.
list.get(0)
대괄호 안의 숫자가get의 괄호 안으로 들어갑니다.
간단한 좌석 배치판을
직접 만들어 보겠습니다.
class SeatMap(private val seats: List<String>) {
operator fun get(index: Int): String {
return seats[index]
}
}
이제 대괄호로 좌석을 꺼낼 수 있습니다.
val map = SeatMap(listOf("김철수", "이영희", "박민수"))
println(map[0]) // 김철수
println(map[1]) // 이영희
값을 넣는 set
값을 꺼내는 것이 get이라면,
값을 넣는 것은 set입니다.
list[0] = "z"
이 코드는 내부에서 이렇게 바뀝니다.
list.set(0, "z")
여기서 순서를 잘 봐야 합니다.
- 대괄호 안의 값(
0) →set의 첫 번째 자리 - 등호 오른쪽의 값(
"z") →set의 마지막 자리
좌석 배치판에 set을 추가해 보겠습니다.
이번에는 값을 바꿀 수 있어야 하므로
안쪽을 MutableList로 바꿉니다.
class SeatMap(private val seats: MutableList<String>) {
operator fun get(index: Int): String {
return seats[index]
}
operator fun set(index: Int, name: String) {
seats[index] = name
}
}
이제 대괄호로 값을 넣을 수 있습니다.
val map = SeatMap(mutableListOf("김철수", "이영희", "박민수"))
map[1] = "정수진" // set(1, "정수진")
println(map[1]) // 정수진
map[1] = "정수진"은map.set(1, "정수진")을 부른 것입니다.
인덱스가 여러 개일 때
get과 set은 대괄호 안에
값을 여러 개 받을 수도 있습니다.
바둑판처럼 가로, 세로 두 좌표가
필요한 경우에 유용합니다.
class Board {
operator fun get(row: Int, col: Int): String {
return "($row, $col) 칸"
}
}
val board = Board()
println(board[2, 3]) // (2, 3) 칸
대괄호 안에 값을 쉼표로 나눠 적으면
그대로 get의 자리들로 전달됩니다.
18.5 Iterator (for에서 순회 가능하게)
for 루프가 도는 원리
우리는 for로 리스트를
하나씩 훑어 봤습니다.
val list = listOf("a", "b", "c")
for (item in list) {
println(item)
}
여기서도 in이라는 기호가 보입니다.
하지만 18.3에서 본 in과는 역할이 다릅니다.
for 안의 in은
“하나씩 순서대로 꺼내라“는 뜻입니다.
이렇게 처음부터 끝까지
차례대로 훑는 것을 순회(iteration)라고 합니다.
iterator라는 약속
for (x in 대상)이 동작하려면
그 대상에 iterator라는 함수가 있어야 합니다.
이터레이터(iterator)란
“다음 값을 하나씩 꺼내 주는 도구“입니다.
비유하자면 이렇습니다.
이터레이터는 사탕이 든 통에서
사탕을 하나씩 꺼내 주는 손과 같다.
“다음 사탕 있니?” 물어보고,
“있으면 하나 꺼내“를 반복한다.
이 “손“은 두 가지 능력을 갖춰야 합니다.
hasNext(): 다음 값이 남아 있는지 (참/거짓)next(): 다음 값을 하나 꺼내기
직접 순회 가능하게 만들기
세 명의 손님을 담는
간단한 대기열 클래스를 만들어 보겠습니다.
iterator 함수가 이터레이터를 돌려주게 하면for에서 바로 쓸 수 있습니다.
class WaitingLine(private val names: List<String>) {
operator fun iterator(): Iterator<String> {
return names.iterator()
}
}
여기서는 직접 만들지 않고,
리스트가 이미 가진 iterator를 빌려 왔습니다.
이제 대기열을 for로 훑을 수 있습니다.
val line = WaitingLine(listOf("김철수", "이영희", "박민수"))
for (name in line) {
println("$name 님 대기 중")
}
출력은 이렇습니다.
김철수 님 대기 중
이영희 님 대기 중
박민수 님 대기 중
line은 리스트가 아니지만,iterator를 갖췄기 때문에
리스트처럼 for로 돌 수 있게 된 것입니다.
for가 도는 조건은 단 하나,
“iterator를 가지고 있는가“이다.
18.6 구조 분해의 동작 원리 (componentN)
구조 분해를 다시 만나다
3장에서 구조 분해(destructuring)를
잠깐 만난 적이 있습니다.
여러 값을 한 번에
여러 변수로 나눠 받는 문법이었습니다.
val (name, age) = Person("홍길동", 20)
println(name) // 홍길동
println(age) // 20
한 줄로 두 변수를 동시에 만들었습니다.
편리해 보이지만, 어떻게 동작하는 걸까요?
이 장에서 그 원리를 밝혀 보겠습니다.
비밀은 componentN 함수
구조 분해는 사실component1, component2 같은 함수를 부르는 것입니다.
이름 끝의 숫자가
몇 번째 값인지를 나타냅니다.
val (name, age) = person
이 코드는 내부에서 이렇게 바뀝니다.
val name = person.component1()
val age = person.component2()
component1(): 첫 번째 값component2(): 두 번째 값
즉, 왼쪽 괄호에 적은 변수 순서대로component1, component2가 차례로 불립니다.
직접 만들어 보는 componentN
일반 클래스에componentN 함수를 직접 붙여 보겠습니다.
물론 operator 표시를 붙여야 합니다.
class Person(val name: String, val age: Int) {
operator fun component1(): String = name
operator fun component2(): Int = age
}
이제 이 클래스도
구조 분해로 나눠 받을 수 있습니다.
val person = Person("홍길동", 20)
val (name, age) = person
println(name) // 홍길동
println(age) // 20
component1이 이름을,component2가 나이를 돌려준 것입니다.
data class가 편한 이유
여기서 중요한 사실이 하나 있습니다.
우리가 방금 손으로 적은 componentN 함수를,
data class는 자동으로 만들어 줍니다.
(data class는 4부에서 자세히 다룹니다.)
data class Person(val name: String, val age: Int)
이 한 줄이면component1, component2가
자동으로 생겨납니다.
그래서 data class는
별다른 준비 없이 바로 구조 분해가 됩니다.
val (name, age) = Person("홍길동", 20)
앞에서 손으로 길게 적었던 코드가,
data class에서는 공짜로 따라오는 것입니다.
Map을 훑을 때도 같은 원리
구조 분해가 실제로 빛나는 순간은
Map을 for로 훑을 때입니다.
val scores = mapOf("김철수" to 90, "이영희" to 85)
for ((name, score) in scores) {
println("$name: $score")
}
출력은 이렇습니다.
김철수: 90
이영희: 85
(name, score) 부분이 바로 구조 분해입니다.
Map의 각 항목이 component1(키)과component2(값)를 갖추고 있어서 가능한 일입니다.
이제 왜 이 문법이 동작하는지
그 속을 이해하게 되었습니다.
18.7 필요한 경우에만 연산자 오버로딩하기 (남용 경계)
편리함에는 대가가 따른다
지금까지 배운 기능은 정말 강력합니다.
내가 만든 클래스에 기호를 붙일 수 있으니까요.
하지만 강력한 도구일수록
조심해서 써야 합니다.
연산자 오버로딩의 가장 큰 위험은
“읽는 사람이 오해할 수 있다“는 점입니다.
기호는 짧습니다.
짧은 만큼 뜻이 모호해지기 쉽습니다.
나쁜 예: 기호가 거짓말을 할 때
예를 들어 사용자를 표현하는 클래스에+를 이렇게 정의했다고 합시다.
class User(val name: String) {
// 나쁜 예: +가 무엇을 하는지 알 수 없다
operator fun plus(other: User): User {
return User(this.name + "와 " + other.name)
}
}
val result = User("철수") + User("영희")
이 코드를 처음 본 사람은 당황합니다.
- 사용자를 “더한다“는 게 무슨 뜻이지?
- 친구를 맺는 건가?
- 계정을 합치는 건가?
기호는 짧아서 편했지만,
정작 의미는 하나도 전해지지 않았습니다.
기호가 상식과 다른 일을 하면,
읽는 사람은 매번 정의를 찾아봐야 한다.
좋은 예: 이름 있는 함수가 나을 때
이럴 때는 차라리
이름이 분명한 함수를 쓰는 편이 낫습니다.
class User(val name: String) {
fun makeFriendWith(other: User): Friendship {
return Friendship(this, other)
}
}
val friendship = user1.makeFriendWith(user2)
makeFriendWith라는 이름을 보면
무엇을 하는지 한눈에 알 수 있습니다.
코드가 조금 길어졌지만,
읽는 사람은 고민할 필요가 없습니다.
언제 연산자 오버로딩을 쓸까
그렇다면 언제 기호를 붙여도 좋을까요?
판단 기준은 의외로 간단합니다.
기호의 원래 뜻과
내가 하려는 일이 자연스럽게 맞아떨어질 때만 쓴다.
좋은 예와 나쁜 예를
표로 정리해 보겠습니다.
| 상황 | 판단 | 이유 |
|---|---|---|
| 돈 + 돈 = 돈 | 좋음 | 더한다는 뜻이 그대로 통한다 |
| 좌표 + 좌표 = 좌표 | 좋음 | 위치를 합친다는 의미가 명확하다 |
| 날짜 < 날짜 | 좋음 | 날짜의 앞뒤 비교는 자연스럽다 |
| 사용자 + 사용자 | 나쁨 | “더한다“의 뜻이 모호하다 |
| 주문 * 상품 | 나쁨 | “곱한다“가 무슨 일인지 알 수 없다 |
핵심은 “상식“입니다.
누가 봐도 자연스럽게 이해되면 기호를 쓰고,
조금이라도 갸웃하게 되면 이름 있는 함수를 씁니다.
코드는 결국 사람이 읽는다
이 장의 교훈을 한 문장으로 줄이면 이렇습니다.
짧은 코드보다
오해 없는 코드가 더 좋다.
연산자 오버로딩은
코드를 짧고 예쁘게 만들어 줍니다.
하지만 그 목적은 “짧음“이 아니라
“더 잘 읽히는 코드“여야 합니다.
기호를 붙였을 때 정말로
더 읽기 쉬워지는지 스스로 물어보세요.
그 답이 “그렇다“일 때만
연산자 오버로딩을 쓰면 됩니다.
18장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 연산자(기호)는 사실
약속된 이름의 함수를 부르는 지름길이라는 점 operator키워드로
내 클래스에+같은 기호를 붙이는 방법compareTo하나로
모든 부등호가 동작하고 정렬까지 되는 원리contains로in연산자를,get과set으로 대괄호 접근을 만드는 법iterator를 갖추면
내 클래스도for로 순회할 수 있다는 점- 구조 분해가 사실
component1,component2를 부르는 것이라는 사실 - 이 편리한 기능을
상식에 맞을 때만 조심스럽게 써야 한다는 태도
연산자 오버로딩은 코틀린이 준
강력한 표현력입니다.
이 힘을 “짧게“가 아니라
“더 잘 읽히게“에 쓰는 것,
그것이 좋은 코틀린 개발자의 습관입니다.